//@version=6
// Machine Learning Fibonacci [FEELS]
// Version 1.0 — 2026-10-16
//
// Draws a Fibonacci grid on the leg in play and marks the fib level where pullbacks of similar
// past legs on this same chart ended.
//
// Swings come from a zigzag: a swing ends when price moves against it by a set number of ATRs.
// Every finished leg whose pullback stayed shallower than the leg itself, and after which the
// trend went on past the leg's extreme, is remembered with two features (its size in ATR and
// its length in bars) and one answer: how deep its pullback went, as a fraction of the leg.
//
// For the leg in play the script finds the most similar remembered legs in the same direction
// (k nearest neighbours on the two features). Each of them counts at the fib level nearest to
// where its pullback ended (1, a full return, counts too), and each fib line is drawn as strong
// as its count: one dot per leg next to the number. The level with the most is the ML level.
// The similar legs themselves are framed on the chart. The exact view shows the raw picture
// instead: a dot per leg at its exact depth, the middle half as a zone, the median as the level.
//
// The leg in play is the move from the last swing point, unless that move is still smaller than
// the leg before it. Then it is that leg's pullback, and the grid stays on that leg.
//
// Nothing here is a forecast. It shows where similar legs on this chart turned in the past.
// Swings and the learning set are decided on closed bars.

indicator("Machine Learning Fibonacci [FEELS]", "ML Fibonacci [FEELS]", overlay = true,
     max_lines_count = 60, max_labels_count = 80, max_boxes_count = 20, max_bars_back = 5000,
     behind_chart = false)
// behind_chart = false draws everything in front of the candles, so the captions stay readable.
// It has to be a constant, so it can't be a setting.

// ─────────────────────────────────────────────────────────────── inputs ────
grpS = "Swings"
kAtr   = input.float(4.0, "A swing ends after a move against it of, × ATR", minval = 1.0, maxval = 20.0, step = 0.5, group = grpS,
     tooltip = "Higher = fewer, bigger legs. Lower = more, smaller legs, and more of them to learn from.")
atrLen = input.int(14, "ATR length", minval = 1, maxval = 200, group = grpS)

grpM = "Model"
knn     = input.int(9, "Similar legs it learns from", minval = 3, maxval = 30, group = grpM,
     tooltip = "How many of the most similar past legs decide the level.")
minHist = input.int(15, "Legs it needs before it draws a level", minval = 5, maxval = 200, group = grpM,
     tooltip = "Until the chart has this many finished legs to learn from, only the grid is drawn.")
maxHist = input.int(150, "Legs it remembers", minval = 20, maxval = 2000, group = grpM,
     tooltip = "The oldest are forgotten first. Fewer = the similar legs it finds are more recent, so more of them are on screen.")
wSize   = input.float(1.0, "Weight of leg size", minval = 0.0, maxval = 5.0, step = 0.1, group = grpM)
wLen    = input.float(1.0, "Weight of leg length", minval = 0.0, maxval = 5.0, step = 0.1, group = grpM)
sameDir = input.bool(true, "Learn only from legs in the same direction", group = grpM,
     tooltip = "On (default): a falling leg is compared only with past falling legs, a rising one with rising ones. Off: with all legs.")

grpL = "Look"
fibStr   = input.string("0.236, 0.382, 0.5, 0.618, 0.786", "Fib levels", group = grpL)
exactView = input.bool(false, "Exact view: a dot per similar leg, the zone and the median", group = grpL,
     tooltip = "Off (default): each similar leg counts at the fib level nearest to where its pullback ended, and each fib line is drawn as strong as its count. On: the raw view, one dot per similar leg at its exact depth, the middle half as a zone, the median as the level.")
showGrid = input.bool(true, "Fib grid in the exact view", group = grpL)
showPr   = input.bool(false, "Prices next to the fib numbers", group = grpL)
gridFrom = input.string("Leg start", "Grid starts at", options = ["Leg start", "Leg end"], group = grpL,
     tooltip = "Leg start draws the grid across the whole leg, like the manual fib tool. Leg end keeps it to the right of the leg.")
showEnds = input.bool(true, "Lines at 0 and 1", group = grpL)
showLeg  = input.bool(true, "Legend", group = grpL)
legPos   = input.string("Bottom right", "Legend position", options = ["Bottom right", "Bottom left", "Top right", "Top left"], group = grpL)
showDiag = input.bool(true, "Dashed line over the leg", group = grpL)
showBand = input.bool(true, "Exact view: gold zone of the middle half", group = grpL)
showDots = input.bool(true, "Exact view: one dot per similar leg", group = grpL)
showSrc  = input.bool(true, "Show the similar legs on the chart", group = grpL,
     tooltip = "Frames each past leg the level was learned from, with its rank by similarity, where its pullback ended, its size and length. Zoom out to see the older ones.")
lvlTx    = input.string("ML LEVEL", "Level word", group = grpL)
srcTx    = input.string("SIMILAR PAST LEG", "Past leg word", group = grpL)
colGold  = input.color(#C9A227, "Model", inline = "c", group = grpL)
colGrid  = input.color(#8B94A1, "Grid", inline = "c", group = grpL)
bandFade = input.int(72, "Zone fade, %", minval = 0, maxval = 100, group = grpL)
gridFade = input.int(40, "Grid fade, %", minval = 0, maxval = 100, group = grpL)
padR     = input.int(30, "Reaches past the last bar, bars", minval = 14, maxval = 200, group = grpL)
txtSize  = input.string("Small", "Text size", options = ["Tiny", "Small", "Normal"], group = grpL)

// ─────────────────────────────────────────────────────────────── helpers ───
txtSz(s) =>
    s == "Tiny" ? size.tiny : s == "Normal" ? size.normal : size.small

fmtP(p) =>
    str.tostring(p, format.mintick)

// ─────────────────────────────────────────────────────────────── fib list ──
var array<float> fibs = array.new<float>()
if barstate.isfirst
    for part in str.split(fibStr, ",")
        v = str.tonumber(str.trim(part))
        if not na(v)
            array.push(fibs, v)

// ─────────────────────────────────────────────────────────────── zigzag ────
atrV = ta.atr(atrLen)
done = barstate.isconfirmed

var int   zDir  = 0
var float zExt  = na
var int   zExtB = na
var array<int>   pB = array.new<int>()
var array<float> pP = array.new<float>()
var array<float> pA = array.new<float>()
var array<int>   pT = array.new<int>()

// learning set: size in ATR, length in bars, pullback depth, direction
var array<float> tS   = array.new<float>()
var array<float> tL   = array.new<float>()
var array<float> tD   = array.new<float>()
var array<int>   tDir = array.new<int>()
var array<int>   tT0  = array.new<int>()
var array<int>   tT1  = array.new<int>()
var array<int>   tT2  = array.new<int>()
var array<float> tP0  = array.new<float>()
var array<float> tP1  = array.new<float>()
var array<float> tP2  = array.new<float>()

newPiv = false
if done and not na(atrV)
    if zDir == 0
        zDir  := 1
        zExt  := high
        zExtB := bar_index
        array.push(pB, bar_index)
        array.push(pP, low)
        array.push(pA, atrV)
        array.push(pT, time)
    else if zDir == 1
        if high > zExt
            zExt  := high
            zExtB := bar_index
        if zExt - low >= kAtr * atrV
            array.push(pB, zExtB)
            array.push(pP, zExt)
            array.push(pA, atrV[bar_index - zExtB])
            array.push(pT, time[bar_index - zExtB])
            zDir   := -1
            zExt   := low
            zExtB  := bar_index
            newPiv := true
    else
        if low < zExt
            zExt  := low
            zExtB := bar_index
        if high - zExt >= kAtr * atrV
            array.push(pB, zExtB)
            array.push(pP, zExt)
            array.push(pA, atrV[bar_index - zExtB])
            array.push(pT, time[bar_index - zExtB])
            zDir   := 1
            zExt   := high
            zExtB  := bar_index
            newPiv := true

// a newly confirmed swing point settles the leg three points back: did its pullback stay inside
// the leg, and did the trend then go past the leg's extreme?
if newPiv and array.size(pP) >= 4
    n  = array.size(pP)
    p0 = array.get(pP, n - 4)
    p1 = array.get(pP, n - 3)
    p2 = array.get(pP, n - 2)
    p3 = array.get(pP, n - 1)
    went = (p1 > p0 and p3 > p1) or (p1 < p0 and p3 < p1)
    if went and math.abs(p2 - p1) <= math.abs(p1 - p0)
        array.push(tS, math.abs(p1 - p0) / array.get(pA, n - 3))
        array.push(tL, float(array.get(pB, n - 3) - array.get(pB, n - 4)))
        array.push(tD, math.abs(p2 - p1) / math.abs(p1 - p0))
        array.push(tDir, p1 > p0 ? 1 : -1)
        array.push(tT0, array.get(pT, n - 4))
        array.push(tT1, array.get(pT, n - 3))
        array.push(tT2, array.get(pT, n - 2))
        array.push(tP0, p0)
        array.push(tP1, p1)
        array.push(tP2, p2)
        if array.size(tS) > maxHist
            array.shift(tS)
            array.shift(tL)
            array.shift(tD)
            array.shift(tDir)
            array.shift(tT0)
            array.shift(tT1)
            array.shift(tT2)
            array.shift(tP0)
            array.shift(tP1)
            array.shift(tP2)

if array.size(pP) > 12
    array.shift(pB)
    array.shift(pP)
    array.shift(pA)
    array.shift(pT)

// ─────────────────────────────────────────────────────────────── leg in play ──
int   lsB   = na
float lsP   = na
int   leB   = na
float leP   = na
nP = array.size(pP)
if nP >= 2 and not na(zExt)
    bL = array.get(pB, nP - 1)
    pL = array.get(pP, nP - 1)
    bQ = array.get(pB, nP - 2)
    pQ = array.get(pP, nP - 2)
    if math.abs(zExt - pL) >= math.abs(pL - pQ)
        lsB := bL
        lsP := pL
        leB := zExtB
        leP := zExt
    else
        lsB := bQ
        lsP := pQ
        leB := bL
        leP := pL
legOk = not na(lsB) and leP != lsP

// ─────────────────────────────────────────────────────────────── nearest neighbours ──
var array<float> nnD = array.new<float>()
var array<int>   nnI = array.new<int>()
var float q1 = na
var float qm = na
var float q3 = na
var string lastKey = ""
var float curF1 = na
var float curF2 = na

key = legOk ? str.tostring(lsB) + "|" + str.tostring(leB) + "|" + str.tostring(leP) + "|" + str.tostring(array.size(tS)) : ""
if legOk and key != lastKey and (done or barstate.islast)
    lastKey := key
    array.clear(nnD)
    array.clear(nnI)
    q1 := na
    qm := na
    q3 := na
    curF1 := math.abs(leP - lsP) / atrV[bar_index - leB]
    curF2 := leB - lsB
    dirC = leP > lsP ? 1 : -1
    cnt = array.size(tS)
    if cnt >= minHist
        s1 = array.stdev(tS)
        s2 = array.stdev(tL)
        s1 := s1 > 0 ? s1 : 1.0
        s2 := s2 > 0 ? s2 : 1.0
        dist = array.new<float>()
        idx  = array.new<int>()
        for i = 0 to cnt - 1
            if not sameDir or array.get(tDir, i) == dirC
                a1 = (array.get(tS, i) - curF1) / s1
                a2 = (array.get(tL, i) - curF2) / s2
                array.push(dist, wSize * a1 * a1 + wLen * a2 * a2)
                array.push(idx, i)
        if array.size(dist) >= knn
            ord = array.sort_indices(dist, order.ascending)
            for j = 0 to knn - 1
                k = array.get(idx, array.get(ord, j))
                array.push(nnD, array.get(tD, k))
                array.push(nnI, k)
            array.sort(nnD, order.ascending)
            q1 := array.get(nnD, int(math.floor(knn * 0.25)))
            qm := array.median(nnD)
            q3 := array.get(nnD, int(math.floor(knn * 0.75)))

// votes: each similar leg counts at the fib level nearest to where its pullback ended. The level 1
// (a full return to the leg's start) is a bucket too, so very deep pullbacks don't all pile onto the
// deepest fib in the list.
var array<float> vLv = array.new<float>()
var array<int> votes = array.new<int>()
var int bestI = -1
if barstate.isfirst
    for f in fibs
        array.push(vLv, f)
    if not array.includes(vLv, 1.0)
        array.push(vLv, 1.0)
nV = array.size(vLv)
if array.size(nnD) > 0 and nV > 0
    array.clear(votes)
    for f in vLv
        array.push(votes, 0)
    for dj in nnD
        bi = 0
        for i = 0 to nV - 1
            if math.abs(array.get(vLv, i) - dj) < math.abs(array.get(vLv, bi) - dj)
                bi := i
        array.set(votes, bi, array.get(votes, bi) + 1)
    bestI := 0
    for i = 0 to nV - 1
        vi = array.get(votes, i)
        vb = array.get(votes, bestI)
        if vi > vb or (vi == vb and math.abs(array.get(vLv, i) - qm) < math.abs(array.get(vLv, bestI) - qm))
            bestI := i
if array.size(nnD) == 0
    bestI := -1

// zone and level in price
legS  = legOk ? leP - lsP : na
zA    = not na(q1) ? leP - q1 * legS : na
zB    = not na(q3) ? leP - q3 * legS : na
zHi   = math.max(zA, zB)
zLo   = math.min(zA, zB)
bestF = bestI >= 0 and bestI < nV ? array.get(vLv, bestI) : na
lvl   = exactView ? (not na(qm) ? leP - qm * legS : na) : (not na(bestF) ? leP - bestF * legS : na)

// ─────────────────────────────────────────────────────────────── drawing (last bar only) ──
var array<line>  dLines  = array.new<line>()
var array<label> dLabels = array.new<label>()
var array<box>   dBoxes  = array.new<box>()

if barstate.islast
    for ln in dLines
        line.delete(ln)
    array.clear(dLines)
    for lb in dLabels
        label.delete(lb)
    array.clear(dLabels)
    for bx in dBoxes
        box.delete(bx)
    array.clear(dBoxes)
    if legOk
        xR = bar_index + padR
        xE = xR - 6
        gCol = color.new(colGrid, gridFade)
        haveModel = not na(qm)
        xG = gridFrom == "Leg start" ? lsB : leB
        if showDiag
            array.push(dLines, line.new(lsB, lsP, leB, leP, color = colGrid, style = line.style_dashed, width = 1))
        tip = haveModel ? str.tostring(knn) + " past legs of this chart most like this one (" + str.tostring(curF1, "0.0") + " ATR, " + str.tostring(curF2, "0") + " bars). Their pullbacks ended between " + str.tostring(array.get(nnD, 0), "0.00") + " and " + str.tostring(array.get(nnD, knn - 1), "0.00") + " of the leg; the middle half between " + str.tostring(q1, "0.00") + " and " + str.tostring(q3, "0.00") + "." : ""
        if showEnds
            for e in array.from(0.0, 1.0)
                if e == 1.0 and not exactView
                    continue
                ye = leP - e * legS
                array.push(dLines, line.new(xG, ye, xE, ye, color = color.new(colGrid, math.min(gridFade + 25, 100)), width = 1))
                array.push(dLabels, label.new(xE, ye, str.tostring(e), style = label.style_label_left, color = color.new(color.white, 100),
                     textcolor = color.new(colGrid, 30), size = txtSz(txtSize)))
        // the grid: weighted by votes in the main view, plain grey in the exact view
        gLv = exactView ? fibs : vLv
        if (not exactView or showGrid) and array.size(gLv) > 0
            for i = 0 to array.size(gLv) - 1
                f = array.get(gLv, i)
                y = leP - f * legS
                v = not exactView and haveModel and array.size(votes) == nV ? array.get(votes, i) : 0
                if not exactView and f == 1.0 and v == 0 and not showEnds
                    continue
                lc = v > 0 ? color.new(colGold, math.max(0, 60 - 15 * v)) : f == 1.0 ? color.new(colGrid, math.min(gridFade + 25, 100)) : gCol
                array.push(dLines, line.new(xG, y, xE, y, color = lc, width = v > 0 ? math.min(1 + v, 6) : 1))
                isBest = i == bestI and not exactView and haveModel
                array.push(dLabels, label.new(xE, y, str.tostring(f) + (v > 0 ? "  " + str.repeat("●", v) : "") + (isBest ? "  " + lvlTx : "") + (showPr ? "  " + fmtP(y) : ""),
                     style = label.style_label_left, color = isBest ? colGold : color.new(color.white, 100),
                     textcolor = isBest ? color.white : v > 0 ? colGold : colGrid, size = txtSz(txtSize), tooltip = isBest ? tip : ""))
        if haveModel
            if exactView
                if showBand
                    array.push(dBoxes, box.new(leB, zHi, xE, zLo, border_color = color.new(colGold, 100), bgcolor = color.new(colGold, bandFade)))
                array.push(dLines, line.new(leB, lvl, xE, lvl, color = colGold, width = 2))
                snap = array.size(fibs) > 0 ? array.get(fibs, 0) : qm
                for f in fibs
                    if math.abs(f - qm) < math.abs(snap - qm)
                        snap := f
                array.push(dLabels, label.new(leB + 2, lvl, lvlTx + " " + str.tostring(qm, "0.00") + "  ≈ " + str.tostring(snap) + (showPr ? "  " + fmtP(lvl) : ""),
                     style = label.style_label_left, color = colGold, textcolor = color.white, size = txtSz(txtSize), tooltip = tip))
                if showDots
                    xs  = xE - 2
                    rep = 0
                    for j = 0 to knn - 1
                        dj = array.get(nnD, j)
                        rep := j > 0 and math.round(dj, 2) == math.round(array.get(nnD, j - 1), 2) ? rep + 1 : 0
                        array.push(dLabels, label.new(xs - 2 * rep, leP - dj * legS, "●", style = label.style_none,
                             textcolor = colGold, size = txtSz(txtSize), tooltip = "a similar past leg: its pullback ended at " + str.tostring(dj, "0.00")))
            // the similar legs themselves, framed, with a caption on top
            if showSrc
                // walk the similar legs in time order: a box that starts before the previous one ended
                // gets its caption on the other side, so neighbouring captions don't print on top of each other
                startT = array.new<int>()
                for k in nnI
                    array.push(startT, array.get(tT0, k))
                byTime  = array.sort_indices(startT, order.ascending)
                prevEnd = 0
                prevUp  = false
                for jt in byTime
                    k    = array.get(nnI, jt)
                    rank = jt + 1
                    p0k = array.get(tP0, k)
                    p1k = array.get(tP1, k)
                    p2k = array.get(tP2, k)
                    dk  = array.get(tD, k)
                    top = math.max(p0k, math.max(p1k, p2k))
                    bot = math.min(p0k, math.min(p1k, p2k))
                    snapK = nV > 0 ? array.get(vLv, 0) : dk
                    for f in vLv
                        if math.abs(f - dk) < math.abs(snapK - dk)
                            snapK := f
                    array.push(dBoxes, box.new(array.get(tT0, k), top, array.get(tT2, k), bot, xloc = xloc.bar_time,
                         border_color = color.new(colGold, 30), bgcolor = color.new(colGold, 84)))
                    array.push(dLines, line.new(array.get(tT0, k), p0k, array.get(tT1, k), p1k, xloc = xloc.bar_time, color = colGold, width = 2))
                    array.push(dLines, line.new(array.get(tT1, k), p1k, array.get(tT2, k), p2k, xloc = xloc.bar_time, color = colGold, style = line.style_dotted, width = 1))
                    array.push(dLabels, label.new(array.get(tT2, k), p2k, "●", xloc = xloc.bar_time, style = label.style_none, textcolor = colGold, size = txtSz(txtSize)))
                    capUp = not (array.get(tT0, k) <= prevEnd and prevUp)
                    // a caption is wider than a short box, so the next box counts as close until one box-length later
                    prevEnd := array.get(tT2, k) + (array.get(tT2, k) - array.get(tT0, k))
                    prevUp  := capUp
                    array.push(dLabels, label.new(array.get(tT0, k), capUp ? top : bot, srcTx + " · turned at " + str.tostring(snapK),
                         xloc = xloc.bar_time, style = capUp ? label.style_label_lower_left : label.style_label_upper_left, color = colGold, textcolor = color.white, size = txtSz(txtSize),
                         tooltip = "#" + str.tostring(rank) + " most similar past leg (" + str.tostring(array.get(tS, k), "0.0") + " ATR, " + str.tostring(array.get(tL, k), "0") + " bars). Its pullback ended at " + str.tostring(dk, "0.00") + " of the leg; nearest fib " + str.tostring(snapK) + "."))
        else
            array.push(dLabels, label.new(leB + 2, leP - 0.5 * legS, "ML is learning · " + str.tostring(array.size(tS)) + " of " + str.tostring(minHist) + " legs",
                 style = label.style_label_left, color = color.new(colGold, 40), textcolor = color.white, size = txtSz(txtSize)))

// ─────────────────────────────────────────────────────────────── legend ────
// white card with dark text, so it reads the same on light and dark charts; the symbols are gold
// like on the chart, the words dark
lPos = legPos == "Bottom left" ? position.bottom_left : legPos == "Top right" ? position.top_right :
     legPos == "Top left" ? position.top_left : position.bottom_right
var table legend = table.new(lPos, 2, 4, bgcolor = color.new(color.white, 15), border_color = color.new(colGrid, 85), border_width = 1)
if barstate.islast and showLeg
    ink = #3C4450
    table.cell(legend, 0, 0, "ML FIBONACCI", text_color = colGold, text_size = txtSz(txtSize), text_halign = text.align_left)
    table.merge_cells(legend, 0, 0, 1, 0)
    table.cell(legend, 0, 1, "●", text_color = colGold, text_size = txtSz(txtSize))
    table.cell(legend, 1, 1, exactView ? "where one similar past leg turned" : "one similar past leg that turned at this level",
         text_color = ink, text_size = txtSz(txtSize), text_halign = text.align_left)
    table.cell(legend, 0, 2, lvlTx, text_color = color.white, bgcolor = colGold, text_size = txtSz(txtSize))
    table.cell(legend, 1, 2, exactView ? "their median · the zone is the middle half" : "where most of them turned",
         text_color = ink, text_size = txtSz(txtSize), text_halign = text.align_left)
    table.cell(legend, 0, 3, showSrc ? "▢" : "", text_color = colGold, text_size = txtSz(txtSize))
    table.cell(legend, 1, 3, showSrc ? "those past legs on the chart" : "", text_color = ink, text_size = txtSz(txtSize), text_halign = text.align_left)

// ─────────────────────────────────────────────────────────────── alerts ────
// main view: touching any fib level that has votes; exact view: entering the zone
touchVoted = false
if not exactView and legOk and nV > 0 and array.size(votes) == nV
    for i = 0 to nV - 1
        yv = leP - array.get(vLv, i) * legS
        if array.get(votes, i) > 0 and low <= yv and high >= yv
            touchVoted := true
inZone  = done and bar_index > leB and (exactView ? (not na(zHi) and low <= zHi and high >= zLo) : touchVoted)
atLevel = done and not na(lvl) and bar_index > leB and low <= lvl and high >= lvl
alertcondition(inZone and not inZone[1], "Price reached a level similar legs turned at", "Price reached a fib level where similar past legs turned (exact view: entered the zone)")
alertcondition(atLevel and not atLevel[1], "Price reached the ML level", "Price reached the ML level")
alertcondition(done and newPiv, "New swing point", "A swing point was confirmed, the leg in play changed")
